feat(cli): flue update — fetch, verify, swap, and restart the daemon - #38
Merged
Conversation
The update story was advisory-only: the daemon asked GitHub every 12h,
the sidebar showed a card with the brew/curl lines, and nothing
self-updated — worse, installing a new binary left the old daemon
serving, because flue enable deliberately never restarts a healthy one.
flue update closes the loop:
- resolves the latest release through release.go's existing fetch
machinery (no second GitHub client) and compares semver; up to date
is one line and exit 0, a dev build is refused with the from-source
answer (git pull && make build)
- a binary resolving into Homebrew's Caskroom is brew's to replace:
brew upgrade karnstack/tap/flue runs when brew is on PATH, and is
named when it is not — never a hand-swap of files brew owns
- script/manual installs download flue_{version}_{os}_{arch}.tar.gz,
verify sha256 against checksums.txt (install.sh's exact contract),
and atomically rename the extracted binary over the running
executable's resolved real path, mode preserved; an unwritable
target refuses with a sudo hint before any download
- service.Manager gains Restart — launchd bootout+bootstrap, systemd
restart, both SIGTERM so sessions snapshot and revive — and the
transcript's last line reports the version the restarted daemon
actually answered with
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
flue's update story was advisory-only. The daemon checks GitHub every 12 hours (
cmd/flue/release.go), the sidebar shows an update card withbrew upgrade karnstack/tap/flue/ the curl one-liner — and that is where it ended. Nothing self-updated, and the sharper half of the problem: after installing a new binary the old daemon keeps serving.flue enabledeliberately never restarts a healthy daemon (its sessions must survive a re-run), and no other restart path existed anywhere. A user could brew-upgrade, see the new version influe version, and still have every shell spawned by last month's build.flue updatecloses the loop end to end.What it does, step by step
Resolve — the latest release comes through
releaseChecker.fetch, the same code and HTTP seam the daemon's 12-hourly check uses; no second GitHub client.fetchnow returns the raw tag (leadingvintact) because the tag is the release's download address;Release()'s cached/rendered form is trimmed exactly as before.Compare —
newer()'s semver comparison. Already newest (or ahead of the tag, the routine state of a build off main): one line, exit 0. A"dev"build is refused politely — it corresponds to no release, and the refusal says exactly how a from-source build updates:git pull && make build.Swap — for script/manual installs: download
flue_{version}_{os}_{arch}.tar.gz, verify its sha256 againstchecksums.txt(the exact contractscripts/install.shand.goreleaser.yamlshare), extract thefluefile at the archive root, and atomicallyrename(2)it over the running executable's real path with the old file's mode preserved.Restart —
service.ManagergainsRestart(). When the login service is installed, the update restarts it and then polls until a daemon identifies itself as the new build — the daemon's version is read fromReleasePath'sCurrentfield, the same authenticated loopback read the sidebar uses — so the final checkmark reports the version that actually answered, not the version the CLI hoped for. No service but a daemon running: the transcript says the daemon still runs the old build and prints the two commands that fix it. Nothing running: nothing to restart.Transcript —
runEnable's voice, checkmark for checkmark:Decisions defended
Brew runs the brew-owned upgrade. If the executable resolves into a Homebrew
Caskroom(orCellar) path, flue never swaps the file itself — brew's bookkeeping would still believe the old version is installed, the nextbrew upgradewould clobber ours, andbrew uninstallwould half-work. Instead, whenbrewis on PATH,flue updaterunsbrew upgrade karnstack/tap/fluewith its output passed straight through, then continues to the restart step — which is the part brew alone can never do. Only when the binary is Caskroom-resolved but brew is somehow absent does it stop at naming the command.Symlinks resolved for the swap, deliberately opposite the service manager.
defaultServiceManagerrecords the unresolvedos.Executable()so the plist survives upgrades (a brew upgrade deletes the version-pinned Caskroom dir; the/opt/homebrew/bin/fluesymlink is the stable name). A file swap needs the opposite: renaming a binary over a symlink would replace the link and orphan the real file, quietly converting a managed install into an unmanaged one.updateTargetresolves throughEvalSymlinksso the swap replaces the bytes and every name pointing at them still does — and the resolution is also what makes Caskroom detection work at all.Rename, never write-in-place. Renaming over a running executable is fine on unix — the running process keeps its inode — while opening it for writing is
ETXTBSYon Linux and corruption elsewhere. The staging file is created first, in the target's own directory: that's the writability probe (an unwritable target refuses with asudo flue updatehint before any bytes are downloaded) and it's what makes the final rename an atomic same-filesystem move. No failure path can leave the target half-written.Restart is graceful by construction. launchd restarts via
bootout+bootstrap(the convergence sequencelaunchd.go's Enable already uses) rather thankickstart -k, because bootout tears the job down with SIGTERM — the signalcmdServesaves session snapshots on — while kickstart kills. systemd is the plainsystemctl --user restart flue, SIGTERM likewise. An update restart exists to carry live sessions onto the new build; the kill spelling would defeat the point.A hand-started daemon is told about, not killed. With no service installed there is no clean stop path (
flue servemay be foreground in someone's terminal), so the transcript printskill <pid> && flue open— SIGTERM snapshots the sessions and the next daemon revives them — instead of yanking a process out from under a terminal that owns it.Test evidence
New tests follow
cmd/flue's existing seams (fake HTTP throughreleaseChecker.get, theservice.Runnerfake,fakeManager, temp-dir binaries via a swappedupdateTarget):TestRunUpdateRefusesADevBuild— refusal names git/make, GitHub never askedTestRunUpdateSaysAlreadyNewest— at the tag and ahead of it; binary never locatedTestRunUpdateRefusesAChecksumMismatch/...ChecksumsWithoutOurEntry— nothing installed, old bytes intact, no staging litterTestRunUpdateSwapsTheBinary— happy path: bytes swapped, 0700 mode preserved, transcript voice, no litterTestRunUpdateRefusesAnUnwritableTarget— sudo hint, refusal lands before any downloadTestRunUpdateHandsABrewInstallToBrew/...PointsAtBrewWhenBrewIsMissingTestRunUpdateRestartsTheServiceAndReportsTheNewVersion— end to end against a realdaemon.Serverreporting 0.6.0TestRunUpdateTellsTheUserAboutAStaleDaemoninternal/service: launchd Restart =bootout,bootstrapwith the exact argv, tolerates an unloaded label, reports bootstrap failure; systemd Restart = the exactsystemctl --user restart flue, reports failure(One unrelated pre-existing flake surfaced once in
internal/transport/relay— aTempDircleanup race inTestRelayPairingRegistersTheDevice; it passes repeatedly in isolation and on the unmodified tree, and that package is untouched here.)Smoke-tested the built binary:
flue updateon the dev build prints the polite from-source refusal, andflue --helplists the new command.Possible follow-up (not in this PR, scoped to CLI + README as asked): the web sidebar's update card could advertise
flue updateahead of the brew/curl lines.🤖 Generated with Claude Code